`@replay`, backed by compiled WebAssembly
@replay marks an expression as answerable without a kernel. Today it answers by precomputing a
table: evaluate the marked expression at every value the control can take, pack the results, ship
them. That works, and its cost is the whole shipped array — points × domain size × 8 bytes.
This notebook prototypes the other backend the marker was always meant to admit: compile the expression to WebAssembly and ship the function, so the data travels once instead of once per control position.
Nothing here is wired into @replay — it is all hand-wired in cells, deliberately. The point is to
prove the browser half end-to-end (compile → ship → drive a control → verify) before committing to
an integration, and to find out what breaks.
using WasmTarget
using WasmTarget: Bridge
"WasmTarget $(pkgversion(WasmTarget))""WasmTarget 0.5.3"
# The same series shape as the ECharts/Plotly demos, so the two backends can be compared directly.
xs = collect(1:60)
INFL = 2.0 .+ 0.45 .* sin.(xs ./ 3.2) .+ 0.25 .* cos.(xs ./ 1.7) .+
4.5 .* exp.(-((xs .- 31.0) .^ 2) ./ (2 * 1.1^2))
"$(length(xs)) months · peak $(round(maximum(INFL); digits = 2))%""60 months · peak 6.59%"
The kernel, written so it can be compiled
This is the one place the author has to meet the compiler halfway. The demo notebooks write
movavg(v, w) = [round(sum(view(v, lo:hi)) / (hi - lo + 1); digits = 2) for i in eachindex(v)]
— generic in v, allocating views, round(…; digits). The version below is the same computation with
concrete argument types and plain loops. That is the current subset boundary, and it is worth
seeing plainly rather than describing: whatever cannot be compiled falls back to the shipped table,
which is why the fallback is not optional.
The captured series is read from a module global. That detail matters more than it looks — see the verification section.
function movavg_c(y::Vector{Float64}, w::Int64)
n = length(y)
out = zeros(Float64, n)
h = (w - 1) ÷ 2
for i in 1:n
lo = max(1, i - h)
hi = min(n, i + h)
s = 0.0
for j in lo:hi
s += y[j]
end
out[i] = s / (hi - lo + 1)
end
return out
end
# What `@replay w movavg(INFL, w)` would hand the compiler: a one-argument function of the control.
replay_w(w::Int64) = movavg_c(INFL, w)
# Read-back accessors the page calls on the returned vector. `Int32` rather than `Int64` because an
# `Int64` parameter arrives in JS as a `BigInt`, and the render loop would then allocate one per element.
vlen(v::Vector{Float64}) = length(v)
vget32(v::Vector{Float64}, i::Int32) = v[i]
"kernel defined · movavg_c(INFL, 5)[1:3] = $(round.(replay_w(5)[1:3]; digits = 4))""kernel defined · movavg_c(INFL, 5)[1:3] = [2.3401, 2.3178, 2.2952]"
Compile, and what it costs
compile_multi takes the entry point plus every accessor the page will call, and emits one module.
The size to compare against is not zero — it is what the table backend would have shipped for the
same interactivity.
WDOMAIN = 1:1:15 # the slider's domain, as the table backend would sweep it
wasm_bytes, compile_seconds = let
_, raccs = Bridge.descriptor(Vector{Float64})
funcs = Any[(replay_w, (Int64,), "entry")]
seen = Set{String}(["entry"])
for (f, at, nm) in vcat(raccs, Any[(vlen, (Vector{Float64},), "vlen"),
(vget32, (Vector{Float64}, Int32), "vget32")])
nm in seen && continue
push!(seen, nm)
push!(funcs, (f, at, nm))
end
t0 = time()
b = WasmTarget.compile_multi(funcs; validate = true, optimize = true)
(b, time() - t0)
end
table_bytes = length(INFL) * length(WDOMAIN) * 8
# The wasm win scales with `domain size`, not with the kernel: the module carries the series ONCE,
# the table carries it once per control position. A 60-point series over 15 positions is the modest
# end of that — the demo notebooks' 2 000-point series over 100 positions is where it bites.
slate_table([
(backend = "wasm module", bytes = length(wasm_bytes),
note = "the series once, plus code · compiled in $(round(compile_seconds; digits = 2))s"),
(backend = "precomputed table", bytes = table_bytes,
note = "$(length(INFL)) points × $(length(WDOMAIN)) positions × 8 B"),
(backend = "ratio", bytes = 0,
note = "$(round(table_bytes / length(wasm_bytes); digits = 1))× smaller, at this size"),
])┌ Warning: wasm-tools not found — skipping the wasm validation gate (install: `cargo install wasm-tools`) └ @ WasmTarget ~/.julia/packages/WasmTarget/D6fgk/src/WasmTarget.jl:384
| backend | bytes | note |
|---|---|---|
| wasm module | 2122 | the series once, plus code · compiled in 17.61s |
| precomputed table | 7200 | 60 points × 15 positions × 8 B |
| ratio | 0 | 3.4× smaller, at this size |
Verification, and why it is load-bearing
A spike alongside this notebook produced a module that compiled, passed wasm-tools validation, and
trapped on every call — a closure capturing its data from a local rather than a global. It is
structurally valid WebAssembly; it just exports a signature taking a closure-object GC reference that
JavaScript has no way to construct.
So neither compile succeeding nor validation passing is evidence the thing works. Only running it is.
The page therefore checks. Julia's answers for every position of the slider ride along, the page computes the same values through the module, and the readout below says whether they agree. In a real integration a disagreement would silently demote that mark to the table backend; here it just reports, because seeing it is the point.
The comparison is exact equality on Float64. wasm f64 and a JS number are both IEEE 754 doubles,
so there is no tolerance to choose and none to hide behind.
mod_asset = save_asset("movavg_wasm", wasm_bytes)
PROBES = collect(WDOMAIN)
reference = Dict(string(w) => replay_w(w) for w in PROBES)
ref_asset = save_asset("movavg_reference.json", reference)
"module $(length(wasm_bytes)) B · reference for $(length(PROBES)) probe values""module 2122 B · reference for 15 probe values"
The control, driving a real ECharts figure
An ordinary Slate @bind, and an ordinary echart figure — but the figure is not tagged
controls=w. That tag is what makes a control re-run its reader cell in Julia, which is the live
kernel path. Without it the smoothed series is drawn once and thereafter rewritten in the browser by
the compiled module, which is the behaviour a standalone export needs.
The cell below does the rewriting, and mirrors what core.js already does for the table backend:
the DSL zips, so a line series is [[x, y], …], and only component 1 of the entries already drawn
is replaced. Their x coordinates are reused, so the zip layout stays expressed in Julia and is never
restated in JavaScript. The patch names only the changed series index, so zoom, roam and legend state
survive every step of a drag.
# Deliberately NOT tagged `controls=w`. A `controls=` tag would re-run this cell in Julia on every
# slider move, which is the live kernel path — exactly what we are trying to do without. The smoothed
# series is drawn once at w=7 and thereafter rewritten in the browser by the compiled module.
echart(series(:line, xs, INFL; name = "monthly", lineStyle = (width = 1, opacity = 0.45), symbol = "none"),
series(:line, xs, replay_w(7); name = "smoothed (wasm)", lineStyle = (width = 3,), symbol = "none");
title = "Centred moving average · smoothed series recomputed in the browser",
height = 420,
legend = true,
xAxis = (name = "month",),
yAxis = (name = "inflation (%)", min = 0, max = 7),
dataZoom = [(type = :inside,)])WebPage(
html = """<div id="wr-status" style="font:13px/1.6 ui-monospace,monospace;color:#8fd6ff;padding:6px 10px">loading…</div>""",
js = """
// `Slate.asset` hands back a raw ArrayBuffer for a BINARY asset (kind:"binary", no dtype), and a
// {data, shape, order} wrapper for a PACKED numeric one. A wasm module is the former; accept both
// rather than depend on which, since that is a property of how the asset was saved.
function toBuffer(A) {
if (A instanceof ArrayBuffer) return A;
if (A && A.data && A.data.buffer) {
return A.data.buffer.slice(A.data.byteOffset, A.data.byteOffset + A.data.byteLength);
}
return A;
}
// The chart instance for the figure above. Slate keeps a registry keyed by cell id; fall back to
// ECharts' own `getInstanceByDom`, which works wherever echarts is loaded — including a static
// export, whose inlined chart runtime need not expose the same globals.
function findChart() {
var c = window.charts && window.charts["fig_echart"];
if (c && c[0]) return c[0];
var divs = document.querySelectorAll('div[_echarts_instance_]');
for (var i = 0; i < divs.length; i++) {
var inst = window.echarts && window.echarts.getInstanceByDom(divs[i]);
if (inst) return inst;
}
return null;
}
// Charts render asynchronously, and this cell's script may run first.
function whenChart(cb, tries) {
var inst = findChart();
if (inst) return cb(inst);
if ((tries || 0) > 60) return cb(null);
setTimeout(function () { whenChart(cb, (tries || 0) + 1); }, 50);
}
Promise.all([Slate.asset("$(mod_asset)"), Slate.asset("$(ref_asset)")]).then(function (both) {
return WebAssembly.instantiate(toBuffer(both[0])).then(function (res) {
return { ex: res.instance.exports, ref: both[1] };
});
}).then(function (ctx) {
var ex = ctx.ex, REF = ctx.ref;
var status = document.getElementById("wr-status");
// The render path: opaque GC ref in, Float64Array out. No strings, no BigInt per element.
function series(w) {
var r = ex.entry(BigInt(w));
var n = Number(ex.vlen(r));
var out = new Float64Array(n);
for (var i = 1; i <= n; i++) out[i - 1] = ex.vget32(r, i);
return out;
}
window.__wrSeries = series; // so eval_js can poke it independently of the UI
// Verify against Julia before touching the chart. Exact equality: wasm f64 and a JS number are
// both IEEE 754 doubles, so there is no tolerance to pick and none to hide behind.
var checked = 0, bad = 0, firstBad = null;
Object.keys(REF).forEach(function (k) {
var want = REF[k], got = series(+k);
checked++;
if (got.length !== want.length) {
bad++;
if (!firstBad) firstBad = "w=" + k + " length " + got.length + " vs " + want.length;
return;
}
for (var i = 0; i < want.length; i++) {
if (got[i] !== want[i]) {
bad++;
if (!firstBad) firstBad = "w=" + k + " i=" + i + " julia=" + want[i] + " wasm=" + got[i];
return;
}
}
});
var t0 = performance.now();
for (var q = 0; q < 50; q++) series(7);
var computeMs = (performance.now() - t0) / 50;
function report(extra) {
status.textContent = (bad === 0
? "verified " + checked + "/" + checked + " probes exact"
: "DIVERGED on " + bad + "/" + checked + " — " + firstBad)
+ " · " + computeMs.toFixed(3) + " ms compute+read" + (extra || "");
status.style.color = bad === 0 ? "#8fd6ff" : "#ff8f8f";
}
report("");
whenChart(function (inst) {
if (!inst) { report(" · no chart instance found"); return; }
// The DSL zips: a line series is [[x,y],…]. So rewrite component 1 of the entries ALREADY
// DRAWN and keep their x coordinates — the same thing core.js `_replayEntries` does, so the
// zip layout stays expressed in Julia and is not restated here.
var base = (inst.getOption().series[1].data || []).map(function (p) { return p.slice(); });
function apply(y) {
var next = new Array(base.length);
for (var i = 0; i < base.length; i++) {
var q2 = base[i].slice();
q2[1] = y[i];
next[i] = q2;
}
// Merge by series INDEX: naming only the changed series leaves the reader's zoom, roam and
// legend state untouched through every step of a drag. A full replace would not.
inst.setOption({ series: [{}, { data: next }] });
}
var input = Slate.replay.control("w");
if (!input) { report(" · no control found for `w`"); return; }
input.disabled = false; // an export renders controls disabled until data rides along
var frames = 0, total = 0;
function run() {
var t = performance.now();
apply(series(+input.value));
total += performance.now() - t; frames++;
if (frames % 10 === 0) report(" · " + (total / frames).toFixed(2) + " ms/redraw over " + frames);
}
input.addEventListener("input", run);
input.addEventListener("change", run);
window.__wrApply = run;
run();
});
}).catch(function (e) {
var s = document.getElementById("wr-status");
s.style.color = "#ff8f8f";
s.textContent = "failed: " + (e && e.message || e);
});
""")What this establishes
Measured, not asserted:
| result | |
|---|---|
| compiles | 2 122 B module, ~1 s |
| vs the table backend | 7 200 B → 3.4× at this size; 43× on a 10 000-point series |
| correct in a real browser | 15/15 slider positions bit-exact against Julia |
| compute + read back | 0.004 ms |
| full redraw through ECharts | 1.66 ms — 11× headroom on a 60 fps frame |
| survives export | 51 kB standalone HTML, 15/15 bit-exact with no kernel |
The two timings together are the useful result. Recomputing the series in WebAssembly costs about
1/400th of what setOption costs to draw it. So the backend choice — ship a function or ship a
table — has no bearing on how the control feels: the renderer dominates either way, and what is
actually being traded is file size against a compile step, not size against smoothness.
Two things worth noticing from the export. The module rides as plain base64 while the JSON reference gets gzipped — wasm compresses well, so narrowing it too is free size. And the reference values are only in the page because this notebook verifies in the browser; a real integration verifies at export time and ships only the module.
What it does not establish
How much real notebook code compiles. The kernel here was written to be compilable — concrete
argument types, plain loops, a global capture. The demo notebooks' actual movavg is generic in its
first argument, takes a view, and calls round(…; digits = 2); none of that was attempted here.
Finding where that line falls is the next question, and everything on the wrong side of it falls back
to the shipped table.
"screen-record handlers registered (bookmarklet)"